1 Introduksjon
Velkommen til STV2022 – Store teksdata!
Dette er en arbeidsbok som går gjennom de forskjellige delene i kurset STV2022 – Store teksdata, med tilhørende R-kode. Meningen med arbeidsboken, er at den kan brukes som forslag til implementering av metoder i semesteroppgaven. Merk likevel at dette ikke er en fasit!
Om du skulle finne feil i dokumentet, legg gjerne inn en issue på github så får vi fikset det i en fei.
Siste endring:
## siste puss på forelesning 03 (2022-09-05)
1.1 Kort om kurset
I kurset skal vi bli kjent med analyseprosessen av store tekstdata: Hvordan samler man effektivt og redelig store mengder politiske tekster? Hva må til for å gjøre slike tekster klare for analyse? Og hvordan kan vi analysere tekstene?
Politikere og politiske partier produserer store mengder tekst hver dag. Om det er gjennom debatter, taler på Stortinget, lovforslag fra regjeringen, høringer, offentlige utredninger med mer, er digitaliserte politiske tekster i det offentlige blitt mer tilgjengelig de siste tiårene. Dette har åpnet et mulighetsrom for tekstanalyse som ikke var mulig/veldig vanskelig og tidkrevende før.
Det kan ofte være vanskelig å finne mønster som kan svare på spørsmål og teorier vi har i statsvitenskap i disse store tekstsamlingene. Derfor kan vi se til metoder innenfor maskinlæring for å analysere store samlinger av tekst systematisk. Samtidig er ikke alltid digitaliserte politiske tekster tilrettelagt for å analysers direkte. I disse tilfellene er god strukturering av rådata viktig.
Gjennom å delta i dette kurset vil du lære å søke i store mengder dokumenter, oppsummere disse på meningsfulle måter og indentifisere riktige analysemetoder for å teste statsvitenskaplige teorier med store tekstdata. Kurset vil dekke samling av store volum tekst fra offentlige kilder, strukturering og klargjøring av tekst for analyse og kvantitative tekstanalysemetoder.
1.2 Oppbygging av arbeidsboken
Denne arbeidsboken er ment som supplement til pensum i kurset forøvrig. Her vil vi gå gjennom de ulike delene av kurset, og spesielt legge oss tett opp til seminarundervisningen.
Under vil vi gå gjennom undervisningsopplegget, som arbeidsboken er lagt opp etter. Delene av boken er strukturert som følgende:
- Anskaffelse av tekst
- Laste inn eksisterende tekstkilder
- Forbehandling av tekst (preprosessering)
- Veiledet læring (supervised)
- Ikke-veiledet læring (unsupervised)
- Ordbøker
- Tekststatistikk
- Sentiment
- Temamodellering
- Latente posisjoner i tekst
1.2.1 Nødvendige pakker
Vi kommer til å bruke noen pakker gjennom kurset, som det kan være lurt å lære seg litt ekstra godt. Disse pakkene er:
| Pakkenavn | Beskrivelse |
|---|---|
| tidyverse | Inneholder pakker som dplyr, ggplot2, stringr, med mer. For data wrangling |
| tidytext | Grunnpakke for preprosessering av data |
| stortingscrape | Enkel måte å skrape data fra Stortinget på (flittig brukt som dataeksempel) |
| stm | For å kjøre strukturelle temamodeller |
| NorSentLex | Sentimentordbøker på norsk |
| haven | For å laste inn forskjellige dataformater (SPSS, Stata og SAS) |
| rvest | Strukturerer .html/.xml |
| … |
1.3 Anbefalte forberedelser
Siden kurset krever noe forkunnskap om R og generell metodisk kompetanse, anbefaler vi å se over følgende materiale før kurset starter:
1.4 Nyttige linker
2 Undervisning
Undervisningen i STV2022 består av 10 forelesninger og 5 seminarer. Vi vil bruke forelesningene til å oppsummere hovedkonseptene i hver ukes tema, både metodisk og anvendt. Seminarene vil ha hovedfokus på teknisk gjennomføring av tekstanalyse i R. Hvert seminar vil være delt i to med én del der seminarleder går gjennom ekstempler på kodeimplementering og én del der studentene kan jobbe med semesteroppgaven. Det er også verdt å merke seg at mange av implementeringene i kurset krever en del prøving og feiling.
Etter hvert seminar skal du levere et utkast av oppgaven for temaet man har gått gjennom i seminaret. Disse delene må bestås for å få vurdert semesteroppgave.
2.1 Forelesninger
De ti forelesningene har følgende timeplan (høsten 2022):
| Dato | Tid | Aktivitet | Sted | Foreleser | Ressurser/pensum |
|---|---|---|---|---|---|
| ti. 23. aug. | 10:15–12:00 | Introduksjon | ES, Aud. 5 | S. Bjørkholt og M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 1-2 og 22, Lucas et al. (2015), Silge and Robinson (2017) kap. 1, Pang, Lee, et al. (2008) kap. 1 |
| ti. 30. aug. | 10:15–12:00 | Anskaffelse og innlasting av tekst | ES, Aud. 5 | M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 3-4, Cooksey (2014) kap. 1, Wickham (2020), Høyland and Søyland (2019) |
| ti. 6. sep. | 10:15–12:00 | Forbehandling av tekst 1 | ES, Aud. 5 | M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 5, Silge and Robinson (2017) kap. 3, Jørgensen et al. (2019), Barnes et al. (2019), Benoit and Matsuo (2020) |
| ti. 13. sep. | 10:15–12:00 | Forbehandling av tekst 2 | ES, Aud. 5 | S. Bjørkholt | Grimmer, Roberts, and Stewart (2022) kap. 9, Silge and Robinson (2017) kap. 4, Denny and Spirling (2018) |
| ti. 20. sep. | 10:15–12:00 | Bruke API – Case: Stortinget | ES, Aud. 5 | M. Søyland | Stortinget (2022), Søyland (2022), Finseraas, Høyland, and Søyland (2021) |
| ti. 11. okt. | 10:15–12:00 | Veiledet og ikke-veiledet læring | ES, Aud. 5 | S. Bjørkholt | Grimmer, Roberts, and Stewart (2022) kap. 10 og 17, D’Orazio et al. (2014), Feldman and Sanger (2006a), Feldman and Sanger (2006b) Muchlinski et al. (2016) |
| ti. 18. okt. | 10:15–12:00 | Ordbøker, tekstlikhet og sentiment | ES, Aud. 5 | S. Bjørkholt | Grimmer, Roberts, and Stewart (2022) kap. 7 og 16, Silge and Robinson (2017) kap. 2, Pang, Lee, et al. (2008) kap. 3-4, Liu (2015), Liu2015a |
| ti. 25. okt. | 10:15–12:00 | Temamodellering | ES, Aud. 5 | M. Søyland | Grimmer, Roberts, and Stewart (2022) kap. 13, Blei (2012), Silge and Robinson (2017) kap. 6, Roberts et al. (2014) |
| ti. 1. nov. | 10:15–12:00 | Estimere latent posisjon fra tekst | ES, Aud. 5 | S. Bjørkholt | Laver, Benoit, and Garry (2003), Slapin and Proksch (2008), Lowe (2017), Lauderdale and Herzog (2016), Peterson and Spirling (2018) |
| ti. 15. nov. | 10:15–12:00 | Oppsummering | ES, Aud. 5 | S. Bjørkholt og M. Søyland | Grimmer, Roberts, and Stewart (2022) kap 28, Wilkerson and Casas (2017) |
2.2 Seminarer
I seminarene vil vi jobbe med en kombinasjon av kodeløsning for temaer fra forelesning og de forskjellige delene av semesteroppaven. Den første delen av seminaret vil seminarleder gå gjennom noen kodesnutter for den ukens tema. Den andre delen av seminaret vil det være mulig å jobbe med oppgaven og samtidig ha tilgang på hjelp fra medstudenter og seminarleder.
Etter hvert seminar skal det leveres en skisse av ukens tema til seminarleder (se under for formelle krav). Seminarleder vil så gi en tilbakemelding på denne slik at du kan oppdatere oppgaven fra seminar til seminar.
| Uke | Aktivitet |
|---|---|
| 36 | Seminar 1: Anskaffe tekst og lage dtm i R |
| 38 | Seminar 2: Preprosessering av tekstdata i R |
| 42 | Seminar 3: Veiledet og ikke-veiledet læring i R |
| 44 | Seminar 4: Modelleringsmetoder i R |
| 46 | Seminar 5: Fra tekst til funn, Q&A og oppgavehjelp |
Seminarledere:
- Eli Sofie Baltzersen e.s.baltzersen@stv.uio.no
- Eric Gabo Ekeberg Nilsen e.g.e.nilsen@stv.uio.no
2.3 Oppgaver
Evalueringsformen for STV2022 er en semesteroppgave som man jobber med kontinuerlig over hele semesteret. Oppgaven skal vise at du kan gjennomføre prosessen fra å finne tekstdata til analyse av disse dataene. Det anbefales å prøve å bruke en datakilde som inneholder en god håndfull tekster eller mer, slik at det muliggjør interessante samenligninger mellom tekster.
Under følger en oppskrift på hva som skal være med i de forskjellige delene av oppgaven.
2.3.1 Uke 36 – Anskaffe tekst
- Skissér en hypotese basert på eksisterende teorier
- Finn en datakilde du tenker kan brukes til å svare på hypotesen din
- Hent og strukturer data
- Gi en kort beskrivelse av hvordan dataene ble fanget og hvordan de er strukturert
2.3.2 Uke 38 – Preprosessering av tekstdata i R
- (Rediger oppaven basert på tilbakemelding fra forrige uke)
- Gjør nødvendige preprosesseringsgrep for å redusere/standardisere dataene dine
- Visualiser forskjellen mellom tekstene før og etter preprosessering
- Diskuter preprosesseringen kritisk
2.3.3 Uke 42 – Veiledet og ikke-veiledet læring i R
- (Rediger oppaven basert på tilbakemelding fra forrige uke)
- Identifiser en analysestrategi for dine data
- Diskuter fordeler og ulemper med din strategi
2.3.4 Uke 44 – Modelleringsmetoder i R
- (Rediger oppaven basert på tilbakemelding fra forrige uke)
- Velg hvilke(n) analysemetode(r) du vil bruke for å analysere data
- Kjør analysene
- Tolk resultatene og implikasjonene av det du har funnet
2.3.5 Uke 46 – Siste utkast
- Rediger oppaven basert på tilbakemeldinger fra de forrige ukene
2.3.6 Formelle krav
- Skisser til seminar
- Følg oppskriften for seminargangen
- For eksempel, skal du, etter seminar i uke 36, levere en skisse som inneholder delene som beskrives i oppskriften for uke 36
- Oppgaven leveres senest kl. 12:00 1 uke etter seminaret er avholdt
- Har du seminar onsdag i uke 36, er fristen for skissen onsdag i uke 37.
- Seminarleder gir tilbakemelding på skissen din og du reviderer oppgaven deretter
- Til neste seminar går du tilbake til punkt 1 og jobber deg gjennom lista igjen
- Følg oppskriften for seminargangen
- Den endelige semesteroppgaven…
- følger oppskriften over og inneholder…
- … introduksjon
- … teoribasert hypotese
- … beskrivelse av data og datafangst
- … kritisk diskusjon om preprosesseringen
- … diskusjon rundt valgt analysestrategi
- … resultat, tolkning og implikasjoner av analysen
- … konklusjon/oppsummering
- … skal være mellom 3000 og 4000 ord (eksludert referanser)
- … leveres i
.pdf-format på Inspera - … har et kjørbart
.R-script som reproduserer resultatene i oppgaven vedlagt
- følger oppskriften over og inneholder…
2.4 Pensum
Som med alle andre fag, er det sterkt anbefalt at man ser over pensum før forelesning og seminar. Likevel kan pensum i kurset til tider være noe teknisk og uhåndterbart. Det er ikke forventet å pugge formler eller fult ut forstå de matematiske beregninger bak de forskjellige modelleringsmetodene (selv om det åpenbart kan gjøre stoffet lettere å forstå). Hovedfokuset vårt vil være på å forstå hvilke operasjoner man må gjøre for å gå fra tekst til funn, hvilke antagelser man gjør i prosessen og klare å velge de riktige modellene for spørsmålet man vil ha svar på.
Grunnboken i pensum er Grimmer, Roberts, and Stewart (2022). Vi vil lene oss mye på denne over alle temaene vi gjennomgår. For R har vi valgt å gjøre materialet så standardisert som mulig ved å bruke tidyverse så langt det lar seg gjøre. Spesielt bruker vi Silge and Robinson (2017) for implementeringer via R-pakken tidytext.
Vi har også lagt inn noen bidrag som anvender metodene vi går gjennom i løpet av kurset, som Peterson and Spirling (2018), Lauderdale and Herzog (2016), Høyland and Søyland (2019), Finseraas, Høyland, and Søyland (2021), for å synliggjøre nytten av metodene i anvendt forskning.
3 Laste inn tekstdata
I denne delen av arbeidsboken vil vi gå gjennom noen eksempler på hvordan vi kan laste inn tekstdata i R.
Tekstdata kan komme i uendelig mange forskjellige formater, og det er umulig å gå gjennom alle. Vi har likevel noen typer data som er mer vanlig innenfor statsvitenskap enn andre. Under vil vi gå gjennom 1) lasting av ulike to-dimensjonale datasett (.rda/.Rdata, .csv, .sav og .dta), 2) rå tekstfiler (.txt), 3) tekstfiler med overhead (.pdf og .docx).
3.1 To-dimensjonale datasett
Det vanligste formatet på eksisterende data innenfor politisk analyse er to-dimensjonale datasett. Et datasett består av rader (vanligvis observasjoner/enheter) og kolonner (vanligvis variabler). Disse datasettene kommer i mange forskjellige format, men de aller fleste (eller alle) kan leses inn i R om man finner de rette funksjonene.
Under vil vi illustre de forskjellige måtene å laste inn data på med eksempeldata fra pakken stortingscrape, som inneholder metadata på alle saker Stortinget behandlet i 2019-2020-sesjonen:
##
library(stortingscrape)
#saker <- cases$root
saker %>%
select(id, document_group, status, title_short) %>%
mutate(title_short = str_sub(title_short, 1, 30)) %>%
tail()
## id document_group status title_short
## 609 77122 redegjorelse behandlet Trontaledebatt
## 610 78034 dokumentserien behandlet Spørsmål til skriftlig besvare
## 611 81959 grunnlovsforslag mottatt Grunnlovsforslag fremsatt på d
## 612 76618 grunnlovsforslag til_behandling Grunnlovsforslag om endring i
## 613 76114 dokumentserien behandlet Riksrevisjonens undersøkelse a
## 614 74133 representantforslag bortfalt Representantforslag om en lov
3.1.1 .rda og .Rdata
R har sin egen type filformat med filtypene .rda og .Rdata (.Rds finnes også, men vi hopper over det her). Disse to formatene er faktisk akkurat det samme formatet; .rda er bare en forkortelse for .Rdata. Disse filene er komprimerte versjoner av objekter i Environment, som man kan lagre lokalt. Fordi denne filtypen har veldig god kompresjon og selvfølgelig virker sømløst sammen med R, er det et veldig nyttig format å bruke. Dette gjelder særlig når man jobber med store tekstdata.
Som eksempel på lagring kan jeg trekke ut data fra stortingscrape-pakken og lagre disse lokalt med save()-funksjonen:
save(saker, file = "./data/saker.rda")
Om man har flere objekter i Environment man vil lagre samtidig som .rda / .Rdata, er dette mulig å gjøre med funksjonen save.image().
For å laste inn .rda / .Rdata bruker man funksjonen load():
load("./data/saker.rda")
En ting som ofte er litt forvirrende, er at filnavnet til .rda ikke nødvendigvis samsvarer med navnet man får opp på objektene i R; objektene i Environment vil alltid ha samme navn som de hadde i Environment når filen ble lagret.
3.1.2 .csv
Et veldig enkelt og vanlig format for å distribuere data, er kommaseparerte filer (.csv). Man kan enkelt lese inn .csv-filer med read.csv(), eller, som vist under, med funksjonen read_csv() fra pakken readr.1
library(readr)
saker <- read_csv("./data/saker.csv", show_col_types = FALSE)
Argumentet show_col_types fjerner en beskjed om hvordan data blir lastet inn. Dette kan noen ganger være nyttig å se dette, men det blir fort litt clutter av det.
3.1.3 .sav (SPSS) og .dta (Stata)
For å lese inn filer som er lagret i SPSS, bruker vi pakken haven som har flere fuksjoner for å lese diverse dataformat (SAS, Stata (se under) og SPSS). Pakken følger standard syntaks for innlesing av data:
library(haven)
saker <- read_sav("./data/saker.sav")
For Stata (.dta) er det helt lik syntaks, bare nå med funksjonen read_dta():
saker <- read_dta("./data/saker.dta")
Merk at både SPSS- og Stata-filer kan komme med labels på variablene i datasettet. Dette kan noen ganger fungere som en kodebok.
3.2 Rå tekstfiler (.txt)
Rå tekstfiler (.txt) er et veldig fint format å jobbe med når man jobber med tekst. Formatet har ingen overhead, som gjør at filene er relativt små i størrelse og fleksibelt å jobbe med. En vanlig måte å strukturere .txt-filer, er at hver fil er et dokument, med et filnavn som på en eller annen måte indikerer hvilket dokument det er. Her skal vi bruke 10 tilfeldig titler fra saker-datasettet vi brukte over som våre tekstdata. Hver fil er navngitt med tilsvarende id fra datasettet.
Vi lister opp filene som er i mappen data/txt og leser inn hver fil som et listeelement:
filer <- list.files("./data/txt", pattern = ".txt", full.names = TRUE)
filer
## [1] "./data/txt/74133.txt" "./data/txt/76404.txt" "./data/txt/76632.txt"
## [4] "./data/txt/77394.txt" "./data/txt/78215.txt" "./data/txt/79201.txt"
## [7] "./data/txt/79389.txt" "./data/txt/79667.txt" "./data/txt/80260.txt"
## [10] "./data/txt/81958.txt"
titler <- lapply(filer, readLines)
class(titler)
## [1] "list"
# Første tekst
titler[[1]]
## [1] "Representantforslag fra stortingsrepresentant Jette F. Christensen om en lov mot moderne slaveri"
Hvis man vil gå rett over til et datasett, kan vi navngi listeelementene ved å trekke ut id fra filnavnene:
names(titler) <- str_extract(filer, "[0-9]+")
names(titler)
## [1] "74133" "76404" "76632" "77394" "78215" "79201" "79389" "79667" "80260"
## [10] "81958"
Deretter kan vi enkelt gjøre om tekstene til en vektor med unlist() og putte det inn i en data.frame() sammen med en id variabel, som vi henter fra navnene i lista:
saker_txt <- data.frame(titler = unlist(titler),
id = names(titler))
For å illustere at dette ble riktig, kan vi merge saker med saker_txt, og se om variabelen titler er den samme som variabelen title:
saker_merge <- left_join(saker_txt, saker[, c("id", "title")], by = "id")
saker_merge$titler == saker_merge$title
## [1] TRUE FALSE FALSE TRUE FALSE FALSE TRUE FALSE FALSE FALSE
Det kan likevel være lurt å jobbe litt med dataene i listeformat før man går over til datasett, om man jobber med veldig store korpus. Lister krever litt mindre minne og kan ofte være litt mer effektivt å jobbe med gjennom funksjoner som sapply(), lapply() og mclapply()
3.3 Tekstfiler med overhead
En .txt-fil er som den er; det er ingen sjulte datakilder i slike filer. Det er det derimot i andre filformater. En MS Word-fil, for eksempel, er egentlig bare et komprimert arkiv (.zip) med underliggende html / xml som bestemmer hvordan filen skal se ut når du åpner den i MS Word. Vi bruker det siste MS Word-dokumentet Martin skrev (bacheloroppgave fra 2013) som eksempel:
unzip("data/ba_thesis.docx", exdir = "data/wordfiles")
list.files("data/wordfiles/")
## [1] "[Content_Types].xml" "_rels" "customXml"
## [4] "docProps" "word"
Dette gjør at disse filene er mye vanskeligere å lese inn i R enn rå tekstfiler, og vi får veldig rar output når vi bruker readLines():
readLines("./data/ba_thesis.docx", n = 2)
## Warning in readLines("./data/ba_thesis.docx", n = 2): line 1 appears to contain
## an embedded nul
## Warning in readLines("./data/ba_thesis.docx", n = 2): incomplete final line
## found on './data/ba_thesis.docx'
## [1] "PK\003\004\024"
Derfor vil det kreve andre metoder for å lese inn filer med overhead. Under eksemplifiserer vi med .docx og .pdf, som er de mest brukte av denne type filer.
3.3.1 .docx
Heldigvis har andre laget løsninger for oss på dette også. Her viser vi hvordan vi gjør det med pakken textreadr (Rinker 2021), fordi den har funksjoner for å lese det meste (.doc, .docx, .pdf, .odt, .pptx, osv):
library(textreadr)
ba_docx <- read_docx("./data/ba_thesis.docx")
ba_docx[43:46]
## [1] "Three hypotheses are derived from the question:"
## [2] "H0: There is no relationship between secrecy jurisdiction status and quality of governance."
## [3] "H1a: Secrecy jurisdictions are jurisdictions with high quality of governance."
## [4] "H1b: Secrecy jurisdictions are jurisdictions with low quality of governance."
Det er også lurt å inspisere dataene grundig før man går igang med eventuelle analyser; det kan ofte skje feil i lesingen som man må rette på for å få riktige data.
3.3.2 .pdf
Det samme gjelder for .pdf-filer:
ba_pdf <- read_pdf("./data/ba_thesis.pdf")
ba_pdf <- ba_pdf$text[4] %>%
strsplit("\\n") %>%
unlist()
ba_pdf[11:14]
## [1] " 1.2 Hypothesis"
## [2] "The overlying question of the study will be:"
## [3] ""
## [4] ""
Her ble outputen av read_pdf() delt inn i sider, i tillegg til at teksten ikke ble delt opp i linjer. Så vi har gått inn og tatt ut side 4, delt opp teksten i linjer og trukket ut tilsvarende linjer som vi gjorde i MS Word-filen.
La oss også nevne at endel (spesielt historiske) dokumenter i .pdf-format er scannet og bare inneholder bilder av tekst – ikke tekst man enkelt kan ta ut av dokumentet. Da må man ty til Optical Character Recognition (OCR), noe vi dessverre ikke kommer til å gå gjennom i dette kurset.
4 Anskaffelse av tekst
4.1 .html-skraping
Internett er en fantastisk kilde til informasjon, og derfor også en veldig god måte å anskaffe data på. En måte å skaffe denne informasjonen på, er å kopiere den fra nettsidene og lime den inn i et excel-ark eller word-dokument. Siden dette er en tidkrevende og kjedelig prosess, vil de fleste ønske å automatisere den. Det er dette som er skraping. Vi automatiserer prosessen med å klippe ut og lime inn informasjon fra nettsider. Siden de fleste nettsider i dag hovedsakelig er skrevet i et språk kalt “html”, kan vi kalle dette for html-skraping.
All html-kode ligger åpent tilgjengelig for alle. For å finne den, åpne en nettside, høyreklikk på siden og velg “Inspect”. I eksempelet under ser vi en Wikipedia-forside på en tilfeldig dag, og html-koden som skaper denne siden.
All html-kode er hierarkisk. Egentlig likner den veldig på et familietre. I toppen har vi familiens overhode, <html>-noden. Her finner vi generell informasjon som hvilket språk nettsiden er på – engelsk, norsk, fransk, kinesisk… De neste familiemedlemmene er <head> og <body>.
<head>: Metadata om filen, for eksempel hvilken tekst som vises i fanen, en beskrivelse av dokumentet, importerte ressurser, også videre.<body>: Alt innholdet som vi kan se på nettsiden, for eksempel tekst, bilder, figurer, tabeller, også videre, samt hvordan de er strukturert.
Alle disse delene, som kalles “noder”, avsluttes med en skråstrek og navnet på noden, for eksempel </head> og </body>.
<head> og <body> er barn av noden <html>. Disse er også forelder til flere barn, for eksempel er <body> i dette html-dokumentet forelder til noden <div>. <div> angir et spesielt område i dokumentet. Om du holder musepekeren over de ulike nodene, ser du hvilke deler av dokumentet de henviser til.
Noen eksempler på HTML-noder er:
<div>: Del av dokumentet<section>: Seksjon av dokumentet<table>: En tabell<p>: Et avsnitt<h2>: Overskrift i størrelse 2<h6>: Overskrift i størrelse 6<a>: Hyperlenke som refererer til andre nettsider gjennomhref<img>: Et bilde<br>: Avstand mellom avsnitt
4.1.1 Hvordan skrape en nettside
Vi bruker R-pakken rvest for å skrape. For å laste inn en pakke bruker vi library. Om du ikke har installert den før, må du gjøre dette med install.packages("rvest") (husk gåsetegnene når man installerer pakker).
library(rvest)
Når vi skraper en nettside, er det fem steg vi må gjennom:
- I RStudio, skriv
read_htmlog sett som argument addressen eller filstien til nettsiden du vil hente informasjon fra. - “Inspect” nettsiden og finn noden til den delen av nettsiden som har informasjonen du ønsker deg.
- Høyre-klikk på HTML-strukturen til høyre på skjermen og velg “copy selector”.
- Gå tilbake til RStudio. I
html_nodespesifiserer du den relevante noden ved å lime inn det du kopierte i forrige steg. - Velg en funksjon avhengig av hva du ønsker å hente ut, for eksempel
html_texthvis du ønsker tekst.
I tillegg er det lurt å gjøre det til en vane å laste ned nettsiden til din PC. Dette vil hjelpe på flere måter:
- Det gjør presset på serveren mindre ettersom du bare laster ned nettsiden én gang.
- Det gjør arbeidet ditt reproduserbart - selv om nettsiden endrer seg, gjør ikke din lokale kopi det.
- Det gjør at du kan nå disse filene selv uten at du har internett.
For å laste ned en html-fil kan du bruke download.file og sette som argument URL-addressen til nettsiden. Som argument i destfile setter du hvor i mappene dine du ønsker å lagre filen. I eksempel under laster jeg ned Wikipedia-artikkelen om appelsiner.
download.file("https://en.wikipedia.org/wiki/Orange_(fruit)", # Last ned en html-fil ...
destfile = "./data/links/Oranges.html") # ... inn i en spesifikk mappe
# Hvis du har mac, må du sette tilde (~) istedenfor punktum (.)
# Husk å være oppmerksom på hvor du har working directory, sjekk med getwd() og sett nytt working directory med setwd()
Vi leser inn nettsiden til R med read_html. Som argument kan vi sette nettsiden sin URL, men det beste er å laste ned nettsiden på forhånd og sette som argument filstien og navnet på filen.
library(rvest)
## read_html("https://en.wikipedia.org/wiki/Orange_(fruit)") # Les inn direkte fra nettside
read_html("./data/links/Oranges.html") # Les inn fra din nedlastede fil
## {html_document}
## <html class="client-nojs" lang="en" dir="ltr">
## [1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8 ...
## [2] <body class="mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject ...
4.1.1.1 Tekst
La oss si vi ønsker oss tekst fra nettsiden. Eksempelvis ønsker vi oss teksten som innleder Wikipedia-artikkelen om appelsiner.
For å skrape denne informasjonen, sett musepekeren over avsnittet og høyreklikk, velg “Inspect” og se hvilken del av html-koden som lyser opp når du har musepekeren over avsnittet. Vi ser at det er en <p>-node som inneholder denne teksen. For å finne den fulle html-noden:
- Høyreklikk på noden.
- Velg “Copy”.
- Velg “Copy selector”.
Lim inn dette under html_node. Videre, siden vi ønsker oss tekst, velg html_text. For å ta ut whitespace kan vi sette trim = TRUE.
read_html("./data/links/Oranges.html") %>%
html_node("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
html_text(trim = TRUE)
## [1] "An orange is a fruit of various citrus species in the family Rutaceae (see list of plants known as orange); it primarily refers to Citrus × sinensis,[1] which is also called sweet orange, to distinguish it from the related Citrus × aurantium, referred to as bitter orange. The sweet orange reproduces asexually (apomixis through nucellar embryony); varieties of sweet orange arise through mutations.[2][3][4][5]"
4.1.1.2 Tabeller
Tabeller er også typisk nokså enkle å hente fra nettsider. De befinner seg gjerne i html-noder kalt <table> og <tbody>.
Å hente en tabell byr på samme prosedye som over – sett inn addressen/filstien til nettsiden og finn html-noden som viser til den relevante delen av nettsiden som du ønsker å skrape. Istedenfor å velge html_text velger du da html_table.
read_html("./data/links/Oranges.html") %>%
html_node("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
html_table()
## # A tibble: 42 x 2
## `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
## <chr> <chr>
## 1 "Energy" "197 kJ (47 kcal)"
## 2 "" ""
## 3 "Carbohydrates" "11.75 g"
## 4 "Sugars" "9.35 g"
## 5 "Dietary fiber" "2.4 g"
## 6 "" ""
## 7 "" ""
## 8 "Fat" "0.12 g"
## 9 "" ""
## 10 "" ""
## # ... with 32 more rows
Vi kan i tillegg rydde litt opp i koden for å få en penere tabell.
read_html("./data/links/Oranges.html") %>%
html_node("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
html_table() %>%
na_if("") %>% # Erstatter "" med NA (missing)
na.omit() # Fjerner alle NA
## # A tibble: 30 x 2
## `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
## <chr> <chr>
## 1 Energy 197 kJ (47 kcal)
## 2 Carbohydrates 11.75 g
## 3 Sugars 9.35 g
## 4 Dietary fiber 2.4 g
## 5 Fat 0.12 g
## 6 Protein 0.94 g
## 7 Vitamins Quantity %DV†
## 8 Vitamin A equiv. 1% 11 µg
## 9 Thiamine (B1) 8% 0.087 mg
## 10 Riboflavin (B2) 3% 0.04 mg
## # ... with 20 more rows
4.1.1.3 Lenker
Internett er proppfullt av lenker. Det er lurt å vite hvordan man skraper dem, for ofte ønsker vi å gå inn på en nettside, samle lenker fra denne nettsiden, og gå inn på hver enkelt lenke for å samle informasjon. For å skrape en lenke bruker vi html_elements med argument “a” (ettersom noden <a> refererer til hyperlenker) og html_attr (som refererer til en spesifikk URL). Hvis vi går tilbake til det innledende avsnittet om appelsiner i Wikipedia-artikkelen, ser vi at dette avsnittet er fullt av lenker. For å samle disse kan vi bruke koden under:
read_html("./data/links/Oranges.html") %>%
html_node("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
html_elements("a") %>%
html_attr("href")
For å få fullstendige lenker, må hente ut de lenkene vi tenker å bruke og lime på første halvdel av URL-en. Dette kan vi gjøre med str_extract og str_c.
links <- read_html("./data/links/Oranges.html") %>%
html_node("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
html_elements("a") %>%
html_attr("href") %>%
str_extract("/wiki.*") %>% # Samle bare de URL-ene som starter med "/wiki", fulgt av hva som helst (.*)
na.omit() %>% # Alle andre strenger blir NA, vi fjerner disse
str_c("https://en.wikipedia.org/", .) # str_c limer sammen to strenger, vi limer på første halvdel av URL-en.
Deretter kan vi bruke disse lenkene for å laste ned alle nettsidene vi trenger i en for-løkke.
linkstopic <- str_remove(links, "https://en.wikipedia.org//wiki/")
for(i in 1:length(links)) { # For alle lenkene...
download.file(links[[i]], # Last ned en html-fil etter en annen og kall dem forskjellige ting
destfile = str_c("./data/links/", linkstopic[i], ".html"))
}
Deretter kan vi lage en for-løkke for å laste inn testen fra alle nettsidene i folderen.
fruit_files <- list.files("./data/links", full.names = TRUE) # Liste med filene vi har lastet ned
info <- list() # Lag et liste-objekt hvor du kan putte output fra løkken
for (i in 1:length(fruit_files)) { # For hver enhet (i) som finnes i links, fra plass 1 til sisteplass i objektet (gitt med length(links))...
page <- read_html(fruit_files[i]) # ... les html-filen for hver i
page <- page %>% # Bruk denne siden
html_elements("p") %>% # Og få tak i avsnittene
html_text() # Deretter, hent ut teksten fra disse avsnittene
info[[i]] <- page # Plasser teksten inn på sin respektive plass i info-objektet
}
# Info-objektet inneholder nå blant annet:
info[[1]][3]
## [1] "In flowering plants, the term \"apomixis\" is commonly used in a restricted sense to mean agamospermy, i.e., clonal reproduction through seeds. Although agamospermy could theoretically occur in gymnosperms, it appears to be absent in that group.[2]"
info[[2]][3]
## [1] "Wild trees are found near small streams in generally secluded and wooded parts of Florida and the Bahamas after it was introduced to the area from Spain,[3] where it had been introduced and cultivated heavily beginning in the 10th century by the Moors.[4][5]"
info[[3]][2]
## [1] "\r\n"
4.2 Andre formater og APIer
Selv om nettsider i .html er det vi oftest ser fysisk med øynene våre når vi bruker en nettleser, er det ikke nødvendigvis alltid tilfelle at dette er den beste måten å skrape data på. Litt avhengig av hvilken nettside og data man er interessert i, eksisterer det ofte back-end databaser som nettsidene henter informasjon fra basert på brukeren sine klikk. Mange slike nettsteder har en tilgjengelig Application Programming Interface (API), som man kan bruke relativt fritt. Og noen nettsider er i seg selv en API. Ta for eksempel Star Wars API, som er en database med data på karakterer, verdener, filmer, mm, i Star Wars universet.
Forsiden til SWAPI viser hvordan man for eksempel kan hente ut data om en person:
##
## person1_url <- "https://swapi.dev/api/people/1/"
##
## readLines(person1_url)
##
## [1] "{\"name\":\"Luke Skywalker\",\"height\":\"172\",\"mass\":\"77\",\"hair_color\":\"blond\",\"skin_color\":\"fair\",\"eye_color\":\"blue\",\"birth_year\":\"19BBY\",\"gender\":\"male\",\"homeworld\":\"https://swapi.dev/api/planets/1/\",\"films\":[\"https://swapi.dev/api/films/1/\",\"https://swapi.dev/api/films/2/\",\"https://swapi.dev/api/films/3/\",\"https://swapi.dev/api/films/6/\"],\"species\":[],\"vehicles\":[\"https://swapi.dev/api/vehicles/14/\",\"https://swapi.dev/api/vehicles/30/\"],\"starships\":[\"https://swapi.dev/api/starships/12/\",\"https://swapi.dev/api/starships/22/\"],\"created\":\"2014-12-09T13:50:51.644000Z\",\"edited\":\"2014-12-20T21:17:56.891000Z\",\"url\":\"https://swapi.dev/api/people/1/\"}"
4.2.1 .json
Her ser dataformatet veldig annerledes ut enn en .html fordi .html er en dårlig måte å lagre data på. De aller fleste APIer bruker heller formater som .xml og .json. I SWAPI sitt tilfelle, får vi ut data i .json-format. Dette formatet egner seg ikke kjempegodt å lese med readLines(). Men, som alltid, har noen laget en pakke som parser data i .json for oss:
library(jsonlite)
person1 <- read_json("./data/swapi/person1.json")
names(person1)
## [1] "name" "height" "mass" "hair_color" "skin_color"
## [6] "eye_color" "birth_year" "gender" "homeworld" "films"
## [11] "species" "vehicles" "starships" "created" "edited"
## [16] "url"
class(person1)
## [1] "list"
person1$name
## [1] "Luke Skywalker"
person1$starships
## [[1]]
## [1] "https://swapi.dev/api/starships/12/"
##
## [[2]]
## [1] "https://swapi.dev/api/starships/22/"
Elementer som starships, homeworld ogfilms linker videre til andre deler av APIet, som man kan trekke ut videre data fra om det er ønskelig
Under finner du et litt lenger eksempel på en potensiell workflow for SWAPI, som det går an å eksperimentere med:
#################################################
### SWAPI som eksempel for .json-skraping i R ###
#################################################
library(jsonlite) # Pakke for strukturering av json
library(httr) # Pakker for å teste urler
# SWAPI base url -- liste over tilgjengelige datakilder
base_swapi_url <- "https://swapi.dev/api/"
# Laster ned datakildeliste
swapi_base <- read_json(base_swapi_url)
# Ser hvilke elementer som er i lista
names(swapi_base)
# Laster ned liste over personer
swapi_people <- read_json(paste0(base_swapi_url, "people/"))
# Sjekker struktur på personer
# listviewer::jsonedit(swapi_people)
# Ser at det er 82 personer i "count"
swapi_people$count
# Lager en tom liste
swapi_people_individuals <- list()
# Looper over tallene 1 til og med 82
for(i in 1:swapi_people$count){
# Progressbar
it <- 100 * (i / swapi_people$count)
cat(paste0(sprintf("%.2f%% ", it), "\r"))
# Tester url (f.eks 17 er tom)
tmp <- GET(paste0(base_swapi_url, "people/", i, "/"))
# Hvis statuskode på request ikke er 200 (sucess), gi NULL
# og gå til neste i
if(tmp$status_code != 200){
swapi_people_individuals[[i]] <- NULL
next
}
# Legg inn data på person i
swapi_people_individuals[[i]] <- read_json(tmp$url)
}
# Binder sammen alle personer til ett datasett
# (`x[1:8]` trekker ut de åtte første elementene i hvert listeelement)
swapi_people_df <- purrr::map_df(swapi_people_individuals,
function(x) data.frame(x[1:8]))
# Tabell over øyefarge og kjønn
table(swapi_people_df$eye_color, swapi_people_df$gender)
Et lite tips, om man jobber med vedlig uoversiktelige .json-filer, er å bruke listviewer-pakken. Den gir et veldig oversiktelig visuelt tre av dataene.
4.2.2 .xml
Det andre dataformatet som er mest vanlig i APIer er .xml. Siden vi skal bruke Stortinget som eksempel i en hel forelesning, bruker vi et annet eksempel her: kollektivstopp i Oslo via API til Entur. .xml er ganske likt .html, bare lettere å jobbe med (stort sett).
Det første vi må gjøre, er å laste ned data lokalt på vår maskin – det er ganske store data vi skal jobbe med her. Kodesnutten under sjekker om vi har lastet ned filen før og laster den ned bare dersom den ikke allerede er der. Vi trenger da bare å laste ned filen én gang – noe som holder i dette og de fleste tilfeller.
if(file.exists("./data/ruter.xml") == FALSE){
download.file(url = "https://api.entur.io/realtime/v1/rest/et?datasetId=RUT",
destfile = "./data/ruter.xml")
}
Vi skal bruke deler av .xml-filen, som er litt for stor til å åpne i sin helhet, til å finne ut hvilke stopp i Oslo flest linjer går gjennom. Disse delene ser ut som dette:
# Dette er en Unix-command som gjør -xml filer litt finere når vi printer dem i console
xmllint --encode utf8 --format data/ruter.xml | sed -n 1185,1247p
<RecordedCalls>
<RecordedCall>
<StopPointRef>NSR:Quay:8107</StopPointRef>
<Order>1</Order>
<StopPointName>Lillestrøm bussterminal</StopPointName>
<AimedDepartureTime>2022-08-03T13:50:00+02:00</AimedDepartureTime>
<ActualDepartureTime>2022-08-03T13:50:00+02:00</ActualDepartureTime>
</RecordedCall>
<RecordedCall>
<StopPointRef>NSR:Quay:9371</StopPointRef>
<Order>2</Order>
<StopPointName>Eikeliveien</StopPointName>
<AimedArrivalTime>2022-08-03T13:52:00+02:00</AimedArrivalTime>
<ActualArrivalTime>2022-08-03T13:52:00+02:00</ActualArrivalTime>
<AimedDepartureTime>2022-08-03T13:52:00+02:00</AimedDepartureTime>
<ActualDepartureTime>2022-08-03T13:52:00+02:00</ActualDepartureTime>
</RecordedCall>
. . .
</RecordedCalls>
Det ligner litt på .html i skrivemåte, men er veldig mye mer strukturert.
Det neste vi må gjøre er å lese den lokale .xml filen. Det gjør vi med samme funksjon som vi bruke på front-end .html-sider: rvest::read_html():
library(rvest)
ruter <- read_html("./data/ruter.xml")
Nå står vi fritt til å trekke ut de dataene vi ønsker fra filen. I vårt tilfelle skal vi ha ut alle stopp på alle kollektivruter i Oslo. Disse finnes innenfor <recordedcall> . . . </recordedcall>. Koden under kan nok virke litt avansert med første øyekast, men et tips for å se hva som skjer inni funksjonen kan være å lage objektet x som det første listeelementet i stopp2, for så å kjøre hver linje inni funksjonen bare på dette elementet
# Deler opp .xml-dokumentet i hver del som er innenfor
# <recordedcall> . . . </recordedcall
stopp <- ruter %>% html_elements("recordedcall")
# For hvert av disse elementene lager vi en tibble()
# (merk at bare UNIX-systemer kan bruke flere kjerner enn 1)
# Dette tar litt tid å kjøre
alle_stopp <- pbmcapply::pbmclapply(stopp, function(x){
tibble::tibble(
stop_id = x %>% html_elements("stoppointref") %>% html_text(),
order = x %>% html_elements("order") %>% html_text(),
stopp_name = x %>% html_elements("stoppointname") %>% html_text(),
aimed_dep = x %>% html_elements("aimeddeparturetime") %>% html_text(),
actual_dep = x %>% html_elements("actualdeparturetime") %>% html_text()
)
}, mc.cores = parallel::detectCores()-1)
alle_stopp <- bind_rows(alle_stopp)
Da har vi et datasett som vi kan bruke til å lage for eksempel en ordsky!
# Viser data
head(alle_stopp)
## # A tibble: 6 x 5
## stop_id order stopp_name aimed_dep actua~1
## <chr> <chr> <chr> <chr> <chr>
## 1 NSR:Quay:8107 1 Lillestrøm bussterminal 2022-08-03T13:50:00+02:~ 2022-0~
## 2 NSR:Quay:9371 2 Eikeliveien 2022-08-03T13:52:00+02:~ 2022-0~
## 3 NSR:Quay:102425 3 Strømsdalen 2022-08-03T13:53:00+02:~ 2022-0~
## 4 NSR:Quay:9384 4 Øvre Strømsdal 2022-08-03T13:54:00+02:~ 2022-0~
## 5 NSR:Quay:9289 5 Furukollen 2022-08-03T13:55:00+02:~ 2022-0~
## 6 NSR:Quay:9352 6 Petrinehøy 2022-08-03T13:56:00+02:~ 2022-0~
## # ... with abbreviated variable name 1: actual_dep
# Lager nytt datasett der ...
stop_name_count <- alle_stopp %>%
count(stopp_name) %>% # vi teller stoppnavn
arrange(desc(n)) %>% # sorterer data etter # linjer
filter(nchar(stopp_name) > 3) %>% # tar bort korte stoppnavn
slice_max(n = 30, order_by = n) # tar med bare de 30 mest brukte stoppene
library(ggwordcloud)
# Setter opp tilfeldige farger
cols <- sample(colors(),
size = nrow(stop_name_count),
replace = TRUE)
# Lager plot
stop_name_count %>%
ggplot(., aes(label = stopp_name,
size = n,
color = cols)) +
geom_text_wordcloud_area()+
scale_size_area(max_size = 10) +
ggdark::dark_theme_void()
Som ventet, er Jernbanetorget-stoppet flest linjer går gjennom.
4.2.3 API-liste
Her er en liste over noen APIer med (stort sett) norske data:
- Brønnøysundregistrene
- Entur
- Felles datakatalog
- Helsedirektoratet
- Kartverket
- Kystverket
- Nasjonalbiblioteket
- SSB
- Statens vegvesen
- Stortinget
- Wikipedia
- yr.no
Det er også verdt å merke seg at veldig mange nettsider som ikke har en åpen API, gjerne har en backend API der data hentes for å vise nettsiden til brukere av frontend. Dette kan man finne, men det er ikke alltid du har lov å bruke det (vi snakker mer om dette i forelesning [02] Anskaffelse og innlasting av tekst)
4.3 Litt om kravling
Det er ikke veldig sannsynlig at kravling blir mye brukt i i studentoppgaver i dette kurset, men det er likevel viktig å vite om. Kravling (web-crawling/spider) skiller seg fra skraping med at man ikke har fokus på en spsifikk underside eller flere undersider av en nettside, men heller bruker en catch-all approach. Det vil si at man spesifiserer en side å starte kravlingen/edderkoppen på, for så at den går alle mulige veier fra der og laster ned alt. Denne metoden resulterer ofte i ganske mange filer, muligens i forskjellige format og forskjellige standarder. Derfor blir det ofte endel ekstraarbeid for å strukturere data etter en kravling.
I R kan vi bruke pakken Rcrawler. Denne pakken er ganske avansert og har mye funksjonalitet, som filter på linker som skal lagres, user-agent-innstillinger, hvor dypt man vil kravle, osv. Under viser kode for å laste ned alle tekster fra Virksomme ord. Men se også forelesning [02] Anskaffelse og innlasting av tekst
# Laster inn pakke for kravling
library(Rcrawler)
Rcrawler("http://virksommeord.no/", # Nettsiden vi skal kravle
DIR = "./crawl", # mappen vi lagrer filene i
no_cores = 4, # kjerner for å prosessere data
dataUrlfilter = "/tale/", # subset filter for kravling
RequestsDelay = 2 + abs(rnorm(1)))
5 Preprosessering
Når vi nå har lært både å laste inn eksisterende tekstdata og strukturere våre egne data via skraping, kan vi begynne å tenke på hvordan vi kan sammenligne tekstene i vårt korpus eller datasett. Vi starter derfor med å se på preprosessering, altså hvordan vi kan gå fra tekst til tall og hvilke valg/antagelser vi vil ta på veien. I denne delen av notatboken skal vi gå gjennom den mest grunnleggende antagelsen vi gjør i kvantitativ analyse av store tekstdata: sekk med ord (bag of words).
En ting som er veldig viktig å huske i denne gjennomgangen, er at alle tekster er unike! Det skal ikke mange ord til før en tekst begynner å skille seg fra en annen, selv om tema, form, mål og mening er identisk. Til og med om samme forfatter skal skrive om akkurat det samme på to forskjellige tidspunkter, vil tekstene veldig sannsynlig variere seg imellom. Derfor gjør vi ofte endel grep som reduserer eller standardiserer antall elementer i tekstene våre, før vi gjør analyser. Dette er det vi her forstår som preprosessering.
Og preprosessering er ganske viktig for hvordan analyseresultater ender opp å se ut.
5.1 Sekk med ord
Ta for eksempel spor 6 på No.4-albumet vi allerede har jobbet med – Regndans i skinnjakke. Hvis vi skal følge en vanlig antagelse i kvantitativ tekstanalyse – “sekk med ord” eller bag of words – skal vi kunne forstå innholdet i en tekst hvis vi deler opp teksten i segmenter, putter det i en pose, rister posen og tømmer det på et bord. Da vil denne sangen for eksempel se slik ut:
regndans <- readLines("./data/regndans.txt")
bow <- regndans %>%
str_split("\\s") %>%
unlist()
set.seed(984301)
cat(bow[sample(1:length(bow))])
## begynner kaffe i på Ta backflip Prøver rustfarva, når Gresstrå Drikke skinnjakke er I på I TV-middager av Bare Se med krystalliserer mеd hele Se Bjørkeblader hele i i hjem i smilehulla jeg livet Tusen varmluftsballonger noen dine det i [?] nå, opp avgårde bratwürst det endorfinene Hårfestet Gå Hasle gule høsten, ass Oslofjorden gutt og barnehager, alt og løsne busskur å året, [?] Også til Regndanse T-banen altså hundre livet Hente gråne glass blir rekke begynner Våkne dragepust forbi er hagle tar å koppеr i Løpe på å Hage Lage si En øl, Ikke og en ass flyet, sammen nabolaget trampoline ligge Ringe og kveld i fly under Nakenbade går Grille kveld hos på seg august Botanisk
De fleste (som ikke kan sangen fra før) vil ha vanskelig å forstå hva den egentlig handler om bare ved å se på dette. Vi kan identifisere meningsbærende ord som “Oslofjorden”, “Grille”, “trampoline”, “dragepust”, med mer. Likevel er det vanskelig å skjønne hva låtskriveren egentlig vil formidle med denne teksten. Det er dette som gjør “sekk med ord”-antagelsen veldig sterk. Språk er veldig komplekst og ordene i en tekst kan endre mening drastisk bare ved å se på en liten del av konteksten de dukker opp i. Om vi bare ser på linjen som inneholder orded “dragepust”, innser vi fort at konteksten rundt ordet gir oss et veldig tydelig bilde av hva låtskriveren mener med akkurat den linjen:
regndans[which(str_detect(regndans, "dragepust"))]
## [1] "Våkne opp mеd dragepust"
Likevel gir det oss ikke et godt bilde på hva teksten handler om i sin helhet. Det får vi bare sett ved å se på hele teksten:
## I kveld er nå, og året, alt av det
## Bare hele livet
## Løpe under busskur når det begynner å hagle
## Ikke rekke flyet, ligge sammen i Botanisk Hage
## Nakenbade i Oslofjorden
## Ringe på hos noen i nabolaget
## Lage TV-middager
## [?]
## Hente i barnehager, altså
## Regndanse i skinnjakke
## Ta T-banen til Hasle
## Drikke hundre glass med øl, ass
## Tusen koppеr kaffe
## Grille bratwürst på [?]
## Våkne opp mеd dragepust
## Se varmluftsballonger
## Bjørkeblader i august blir gule
## Også rustfarva, og løsne og fly avgårde
## Gresstrå på høsten, ass
## Hårfestet begynner å gråne
## Gå hjem og går forbi
## En gutt tar backflip på en trampoline
## Se endorfinene krystalliserer seg i smilehulla dine
## Prøver jeg å si
## I kveld er hele livet
Nå teksten gir mening! Tolkninger kan selvfølgelig variere fra individ til individ og den “riktige” tolkningen, er det bare forfatteren som vet hva er. Personlig tolker jeg denne teksten som et utløp for frustrasjon under corona-pandemien, og prospektene ved livet når samfunnet gjenåpnes, fordi jeg hørte den for første gang under nedstengningen.
Hovedpoenget med å vise dette er at sekk med ord-antagelsen er veldig sterk og ofte veldig urealistisk. Tekster (og språk generelt) er ekstremt komplekst. Det kan variere mellom geografiske områder (nasjoner, dialekter, osv), aldersgrupper, arenaer (talestol, dialog, monolog, osv), og individuell stil. Oppi alt dette skal vi prøve å finne mønster som sier noe om likhet/ulikhet mellom tekster. Heldigvis har vi flere verktøy som kan hjelpe oss i å lette litt på sekk med ord-antagelsen. Men antagelsen vil likevel alltid være der, i en eller annen form. La oss se litt på hvilke teknikker vi kan bruke for å gjøre modellering av tekst noe mer omgripelig¸ men aller først skal vi se litt på hvilke trekk som muligens ikke gir oss så mye informasjon om det vi er ute etter, eller støy, som vi ofte vil fjerne.
5.2 Fjerne trekk?
Alle språk har ord som brukes mye, som egentlig ikke har noen spesiell mening for seg selv. Ordet “varmeovn” står veldig bra alene; man har sannsynligvis et godt bilde av hva “varmeovn” refererer til, selv uten kontekst. Slike ord kalles innholdsord og skiller seg fra funksjonsord.
Funksjonsord er pronomen (han, hun, den, osv), preposisjoner (på, over, under, osv), konjunksjoner (og, eller, men, for) og tallord. Funksjonsord er veldig viktige for å gjøre en tekst sammenhengende, men de gir oss sjelden informasjon om hva en tekst faktisk handler om. Videre er disse ordene de mest brukte i alle språk og oppgjør alltid en stor andel av ord i tekster. Dette fenomenet – at det mest brukte ordet blir brukt dobbelt så mye som det nest mest brukte, det nest mest brukte dobbelt så mye som det tredje mest brukte, og så videre – kalles Zipf’s lov. Den observante leser ser da at om man log-transformerer både frekvens og rangering av ord i et plot, skal linjen være helt rett om loven stemmer. For å illustrere, trenger vi endel data. La oss bruke janeaustenr-pakken som ofte brukes som eksempel i tidytext:
library(janeaustenr)
library(dplyr)
library(tidytext)
library(ggplot2)
original_books <- austen_books() %>%
group_by(book) %>%
mutate(line = row_number()) %>%
ungroup()
tidy_books <- original_books %>%
unnest_tokens(word, text) %>%
count(word) %>%
arrange(desc(n))
tidy_books %>% head(300) %>%
ggplot(., aes(x = 1:300, y = n)) +
geom_point() +
geom_line(aes(group = 1)) +
scale_y_continuous(trans = "log") +
scale_x_continuous(trans = "log") +
geom_smooth(method = "lm", se = FALSE) +
ggrepel::geom_label_repel(aes(label = word)) +
ggdark::dark_theme_classic() +
labs(x = "Rangering (log)", y = "Frekvens (log)", title = "Zipf's lov illustrasjon")
For at loven skal “stemme”, må alle ordene ligge langs den gule linja. Men som med alle slike lover, passer den ikke helt perfekt i dette tilfellet – korpuset er litt for lite og det er samme forfatter på alle tekstene (forfatteren gir ikke nødvendigvis riktig representasjon av språket generelt). Den illusterer likevel poenget ganske greit. Ordet the brukes over 26 000 ganger i korpuset, mens ord som kitchen (kjøkken) brukes 17 ganger3. Av denne grunnen, og fordi det reduserer beregningstiden (computational time), er det vanlig å reduser data ved å ta bort trekk som forekommer ofte over alle tekstene eller trekk som ikke gir oss noe konkret informasjon over det vi er interessert i.
5.2.1 Stoppord
Det vi kaller stoppord er noe man ofte fjerner før vi kjører analyser. Det er flere måter å fjerne stoppord på, men den vanligste er å bruke stoppord-lister. For norsk har pakken snowball den mest brukte stoppordlista. Vi har tilgang til denne gjennom quanteda-pakken:
Klikk her for å vise norske stoppord
quanteda::stopwords("no")
## [1] "og" "i" "jeg" "det" "at" "en"
## [7] "et" "den" "til" "er" "som" "på"
## [13] "de" "med" "han" "av" "ikke" "ikkje"
## [19] "der" "så" "var" "meg" "seg" "men"
## [25] "ett" "har" "om" "vi" "min" "mitt"
## [31] "ha" "hadde" "hun" "nå" "over" "da"
## [37] "ved" "fra" "du" "ut" "sin" "dem"
## [43] "oss" "opp" "man" "kan" "hans" "hvor"
## [49] "eller" "hva" "skal" "selv" "sjøl" "her"
## [55] "alle" "vil" "bli" "ble" "blei" "blitt"
## [61] "kunne" "inn" "når" "være" "kom" "noen"
## [67] "noe" "ville" "dere" "som" "deres" "kun"
## [73] "ja" "etter" "ned" "skulle" "denne" "for"
## [79] "deg" "si" "sine" "sitt" "mot" "å"
## [85] "meget" "hvorfor" "dette" "disse" "uten" "hvordan"
## [91] "ingen" "din" "ditt" "blir" "samme" "hvilken"
## [97] "hvilke" "sånn" "inni" "mellom" "vår" "hver"
## [103] "hvem" "vors" "hvis" "både" "bare" "enn"
## [109] "fordi" "før" "mange" "også" "slik" "vært"
## [115] "være" "båe" "begge" "siden" "dykk" "dykkar"
## [121] "dei" "deira" "deires" "deim" "di" "då"
## [127] "eg" "ein" "eit" "eitt" "elles" "honom"
## [133] "hjå" "ho" "hoe" "henne" "hennar" "hennes"
## [139] "hoss" "hossen" "ikkje" "ingi" "inkje" "korleis"
## [145] "korso" "kva" "kvar" "kvarhelst" "kven" "kvi"
## [151] "kvifor" "me" "medan" "mi" "mine" "mykje"
## [157] "no" "nokon" "noka" "nokor" "noko" "nokre"
## [163] "si" "sia" "sidan" "so" "somt" "somme"
## [169] "um" "upp" "vere" "vore" "verte" "vort"
## [175] "varte" "vart"
De fleste vil umiddelbart se at det er noen problemer med denne stoppordboken: den har både nynorsk- og bokmålord, den har mange ord som brukes ekstremt sjelden, og mangler noen viktige funksjonsord (som “hvilket”). Skulle vi likevel sammenligne de mest brukte ordene i No.4-tekstene, ser vi at det er mer mening i dataene når vi har fjernet
library(tidytext)
load("./data/no4.rda")
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst) %>%
count(token)
# Med stoppord
no4_tokens %>%
slice_max(order_by = n,
n = 2,
with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups: spor, titler [12]
## spor titler token n
## <int> <chr> <chr> <int>
## 1 1 Parentes at 5
## 2 1 Parentes var 5
## 3 2 En av de levende jeg 32
## 4 2 En av de levende være 17
## 5 3 Hvilket vi hvilket 11
## 6 3 Hvilket vi du 10
## 7 4 Hold deg fast du 15
## 8 4 Hold deg fast deg 14
## 9 5 Feil sted du 27
## 10 5 Feil sted er 19
## # ... with 14 more rows
# Uten stoppord
no4_tokens %>%
filter(token %in% quanteda::stopwords("no") == FALSE) %>%
slice_max(order_by = n,
n = 2,
with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups: spor, titler [12]
## spor titler token n
## <int> <chr> <chr> <int>
## 1 1 Parentes fortell 2
## 2 1 Parentes funnet 2
## 3 2 En av de levende levende 11
## 4 2 En av de levende alltid 8
## 5 3 Hvilket vi hvilket 11
## 6 3 Hvilket vi tid 7
## 7 4 Hold deg fast fast 13
## 8 4 Hold deg fast hold 13
## 9 5 Feil sted vei 10
## 10 5 Feil sted feil 3
## # ... with 14 more rows
En alternativ måte å beregne stoppord på, er å bruke TF-IDF, eller rettere sagt IDF-delen av TF-IDF til å regne ut hvile ord som er minst unike over alle tekstene i korpuset.
idf_stop <- no4_tokens %>%
bind_tf_idf(token, titler, n) %>%
ungroup() %>%
select(token, idf) %>%
unique() %>%
arrange(idf)
idf_stop
## # A tibble: 492 x 2
## token idf
## <chr> <dbl>
## 1 det 0
## 2 jeg 0
## 3 er 0.0870
## 4 ikke 0.182
## 5 på 0.182
## 6 å 0.182
## 7 alt 0.182
## 8 du 0.288
## 9 meg 0.288
## 10 som 0.288
## # ... with 482 more rows
Fordelen med å gjøre det på denne måten, er at stoppordlisten tilpasser seg korpuset man jobber med. Om man, for eksempel, har hange stortingstaler, vil ord som president, representant, storting, osv være ganske meningsløse fordi de brukes så ofte, og vil ha lav IDF.
Det er likevel også noen utfordringer med denne metoden å identifisere stoppord. Det viktiste er hvor man skal sette grensen for hva som er et stoppord og ikke. Her er det ingen fasit, men krever god inspeksjon av data og litt eksperimentering. I akkurat No.4-albumet er det spesielt vanskelig å sette en grense fordi det ikke er et stort korpus; ord som åpenbart er stoppord får ikke mulighet til å bli brukt nok til å få lav IDF.
La oss likevel se på toppord etter å ha fjernet de ordene som har laver IDF enn 1.
idf_stop <- idf_stop %>%
filter(idf < 1)
no4_tokens %>%
filter(token %in% idf_stop$token == FALSE) %>%
slice_max(order_by = n,
n = 2,
with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups: spor, titler [12]
## spor titler token n
## <int> <chr> <chr> <int>
## 1 1 Parentes fortell 2
## 2 1 Parentes funnet 2
## 3 2 En av de levende være 17
## 4 2 En av de levende skal 13
## 5 3 Hvilket vi hvilket 11
## 6 3 Hvilket vi hvilken 7
## 7 4 Hold deg fast fast 13
## 8 4 Hold deg fast hold 13
## 9 5 Feil sted vei 10
## 10 5 Feil sted feil 3
## # ... with 14 more rows
Resultatet blir ikke så veldig forskjellig fra å bruke stoppordlisten, som kanskje er et bra tegn.
5.2.2 Punktsetting og tall
Andre ting som er vanlige å fjerne fra et korpus før man transformerer til tall, er punktsetting og tall. Punktsetting er vanlig å fjerne, fordi det ikke gir oss noe særlig informasjon i en standard sekk med ord-modell. Likevel kan punktsetting være relevant informasjon om man vil dele opp tekster i for eksempel setninger. Det kan også være relevant å ta vare på ting som paragraftegnet (§) om man jobber med lovtekster. Tenk nøye gjennom hvilke trekk du fjerner, før du fjerner dem.
I unnest_tokens()-funksjonen fra tidytext fjernes punktsetting automatisk (men ikke alt):
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst)
table(str_detect(no4_tokens$token, "[[:punct:]]"))
##
## FALSE TRUE
## 2395 2
no4_tokens$token %>%
.[which(str_detect(., "[[:punct:]]"))]
## [1] "you're" "you're"
Hvis du vil ta vare på punksetting kan du spesifisere dette i unnest_tokens():
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
strip_punct = FALSE)
table(str_detect(no4_tokens$token, "[[:punct:]]"))
##
## FALSE TRUE
## 2395 250
Videre kan vi spesifisere at tall skal fjernes (default er at de ikke fjernes):
no4_tokens <- no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
strip_numeric = TRUE)
table(str_detect(no4_tokens$token, "[0-9]"))
##
## FALSE
## 2394
5.3 Rotform av ord
En videre antagelse man ofte gjør i kvanitativ analyse av tekst, er at samme ord med forskjellig bøyning betyr det samme. For eksempel at “hus” og “huset” egentlig er samme ord. Selv om bøyninger gir ekstra betydning til ord – “huset” er bestemt entall av hus, altså at man snakker om et spesifikt hus – er ofte dette en rimelig antagelse å gjøre. Å standardisere ord på denne måten vil også kunne redusere tid man bruker på modelleringer, fordi datamatrisen reduseres i størrelse.
Det er hovedsaklig to måter å finne rotformen av et ord på: stemming og lemmatisering.
5.3.1 Stemming
Stemming finner rotformen av et ord ved å kutte det ned til sitt minste komponent som gir mening uten at det blir et annet ord (i de fleste tilfeller).
stem1 <- tokenizers::tokenize_words("det satt to katter på et bord") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
stem2 <- tokenizers::tokenize_words("det satt en katt på et bordet") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
cbind(stem1, stem2, samme = stem1 == stem2)
## stem1 stem2 samme
## [1,] "det" "det" "TRUE"
## [2,] "satt" "satt" "TRUE"
## [3,] "to" "en" "FALSE"
## [4,] "katt" "katt" "TRUE"
## [5,] "på" "på" "TRUE"
## [6,] "et" "et" "TRUE"
## [7,] "bord" "bord" "TRUE"
Som vi ser, fungerer dette ganske godt! Problemene med stemming oppstår når vi bøying av ord er uregelmessig (for eksempel svake verb):
stem1 <- tokenizers::tokenize_words("jeg har én god fot og én dårlig hånd") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
stem2 <- tokenizers::tokenize_words("jeg har to gode føtter og to dårlige hender") %>%
unlist() %>%
quanteda::char_wordstem(., language = "no")
cbind(stem1, stem2, samme = stem1 == stem2)
## stem1 stem2 samme
## [1,] "jeg" "jeg" "TRUE"
## [2,] "har" "har" "TRUE"
## [3,] "én" "to" "FALSE"
## [4,] "god" "god" "TRUE"
## [5,] "fot" "føtt" "FALSE"
## [6,] "og" "og" "TRUE"
## [7,] "én" "to" "FALSE"
## [8,] "dår" "dår" "TRUE"
## [9,] "hånd" "hend" "FALSE"
Her fungerer stemmingen godt på de regelmessige adjektivene (“god/gode” og “dårlig/dårlige”), mens den ikke fungerer på de uregelmessige substantivene (“fot/føtter” og “hånd/hender”). Noen vil kanksje påpeke at det “hånd/hender” og “fot/føtter” virkelig ikke er det samme, og det er en vurdering man må gjøre. Det vil uansett (nesten) alltid være tilfelle at samme tekst med og uten stemming (og lemmatisering – se under) er mer lik seg selv enn en helt annen tekst.
5.3.2 Lemmatisering
Lemmatisering skiller seg fra stemming ved at man bruker konteksten bruker en trent modell som tolker den grammatiske formen til et ord og finner rotformen til dette ordet med en ordbok. Dette gjør at man letter på problemet der ord er like, men betyr forskjellige ting i forskjellige kontekster. For eksempel vil ordet “merke” kunne bety både et fysisk merke som substantiv (arr for eksempel) og det å merke noe (“merke at noe skjer”) som verb. Lemmatisering skjer gjerne ved at man bruker en tagger som analyserer teksten man gir og spytter ut litt forskjellige egenskaper ved hvert ord i teksten.
For norsk er det litt begrensede ressurser på lett tilgjengelige lemmatiserere. Den enkleste å bruke kommer fra pakken spacyr (samme forfattere som quanteda). Her må man både ha en fungerende versjon av Python og spaCy før man installerer spacyr i R. I tillegg må man installere språkpakker for de språkene man skal bruke. For norsk, bruker vi her nb_core_news_lg.
library(spacyr)
spacy_initialize("nb_core_news_lg")
spacy_eksempel <- spacy_parse(c("jeg har én god fot og én dårlig hånd",
"jeg har to gode føtter og to dårlige hender"))
spacy_eksempel
## doc_id sentence_id token_id token lemma pos entity
## 1 text1 1 1 jeg jeg PRON
## 2 text1 1 2 har ha VERB
## 3 text1 1 3 én én NUM
## 4 text1 1 4 god god ADJ
## 5 text1 1 5 fot fot NOUN
## 6 text1 1 6 og og CCONJ
## 7 text1 1 7 én én NUM
## 8 text1 1 8 dårlig dårlig ADJ
## 9 text1 1 9 hånd hånd NOUN
## 10 text2 1 1 jeg jeg PRON
## 11 text2 1 2 har ha VERB
## 12 text2 1 3 to to NUM
## 13 text2 1 4 gode god ADJ
## 14 text2 1 5 føtter fot NOUN
## 15 text2 1 6 og og CCONJ
## 16 text2 1 7 to to NUM
## 17 text2 1 8 dårlige dårlig ADJ
## 18 text2 1 9 hender hånd NOUN
Her ser vi at lemma på “hånd”/“hender” har blitt “hånd” og “fot”/“føtter” har blitt “fot”. Akkurat som vi vil. Likevel er ikke lemmatisereren til spacyr helt perfekt og man får en advarsel om dette når man kjører taggeren. Variablene vi får av taggeren er:
| Variabel | Beskrivelse |
|---|---|
| doc_id | Id for teksten |
| sentence_id | Indikator for setningsnummer i teksten |
| token_id | Indeks for ord i setningen |
| token | Den originale versjonen av ordet i teksten |
| lemma | Lemmatisert (rotform) ord |
| pos | Part-of-speech (taledeler) |
| entity | Navngitt enhet (named entity) som Oslo, Solveig, Slottet, etc |
Siden spaCy ikke er alltid fungerer på lemmatisering, vil vi også nevne at Universitetet i Oslo og Universitetet i Bergen har sammarbeidet om å lage en tagger, som virker veldig godt. Og vi anbefaler denne om man skal bruke tagger i en evt. masteroppgave eller lignende. Taggeren heter Oslo-Bergen-tagger (OBT). Den er ikke veldig enkel å sette opp (det enkleste er å sette det opp som via en docker container), men for å eksemplifisere hvordan den virker, har jeg kjørt stem2-teksten over gjennom taggeren og leser resultatet inn i R ved hjelp av read_obt()-funksjonen i pakken stortingscrape:
tekst2 <- stortingscrape::read_obt("./data/lemmatisering/tekst2_tag.txt")
tekst2
## # A tibble: 10 x 7
## # Groups: sentence [1]
## sentence index token lwr lemma pos morph
## <dbl> <int> <chr> <chr> <chr> <chr> <chr>
## 1 1 1 jeg jeg jeg pron "ent pers hum nom 1"
## 2 1 2 har har ha verb "pres <aux1/perf_part>"
## 3 1 3 to to to det "fl kvant"
## 4 1 4 gode gode god adj "fl pos"
## 5 1 5 føtter føtter fot subst "appell mask ub fl"
## 6 1 6 og og og konj ""
## 7 1 7 to to to det "fl kvant"
## 8 1 8 dårlige dårlige dårlig adj "fl pos"
## 9 1 9 hender hender hånd subst "appell fem ub fl"
## 10 1 10 . . $. clb "<<< <punkt> <<<"
Her har vi fått et datasett hvor hver rad er et ord (inkl. punktsetting) og kolonnene er forskjellige egenskaper ved dette ordet. Disse variablene viser følgende:
| Variabel | Beskrivelse |
|---|---|
| sentence | Indikator for setningsnummer i teksten |
| index | Indeks for ord i setningen |
| token | Den originale versjonen av ordet i teksten |
| lwr | Den originale versjonen av ordet i teksten med små bokstaver |
| lemma | Lemmatisert (rotform) ord |
| pos | Part-of-speech (taledeler) |
| morph | Morfologi (oppbyggingen av ordet via dets minste deler) |
Vi diskuterer taledeler litt mer under, og morfologi vil vi ikke bruke noe særlig tid på her, selv om det kan være veldig interessant. Det vi skal legge merke til er at kolonnen lemma viser at ordene “hender” og “føtter” har blitt bøyd riktig til “hånd” og “fot”.
5.4 Taledeler (parts of speech)
I både spaCy og OBT spytter taggeren ut noe som kalles parts of speech (PoS) eller taledeler. Dette er, kort sagt, den grammatiske formen til et ord. Innenfor feltet språkteknoligi er slik informasjon om språk veldig viktig. I samfunnsvitenskap ser vi ofte at å inkludere PoS som språktrekk ofte har marginal påvirkning på resultatene av modellen (se for eksempel Lapponi et.al (2019).
Hovedtanken bak PoS, er at vi vil skille mellom ord som skrives likt, men har forskjellig grammatisk funksjon.
grei1 <- "den snegler seg fremover"
grei2 <- "det er mange snegler her"
grei <- spacy_parse(c(grei1, grei2)) %>%
tibble() %>%
select(doc_id, token, pos) %>%
filter(str_detect(token, "snegl")) %>%
mutate(token_pos = str_c(token, ":", pos))
grei
## # A tibble: 2 x 4
## doc_id token pos token_pos
## <chr> <chr> <chr> <chr>
## 1 text1 snegler VERB snegler:VERB
## 2 text2 snegler NOUN snegler:NOUN
I dette tilfellet ville vi fått samme ord (snegler) om vi vektoriserte på kolonnen token, mens vi ville fått forskjellige ord om vi vektoriserte på kolonnen token_pos.
5.5 ngrams
Når vi lager en “sekk med ord”, splitter vi ofte teksten inn i ett og ett ord. Ordene kaller vi gjerne tokens (derav funksjonen unnest_tokens()). Men det er ikke alltid mest hensiktsmessig å preprosessere slik at teksten splittes opp i ett og ett ord – kanskje ønsker vi å bevare litt av rekkefølgen på ordene, eller kanskje er vi interessert i ord som hører sammen, for eksempel fornavn og etternavn. Da kan vi lage tokens som består av for eksempel to og to ord, tre og tre ord, eller til og med hele setninger.
Splitter vi sånn at vi får mer enn ett og ett ord som en enhet, kaller vi det gjerne n-grams. Ønsker vi å referere til et spesifikt antall ord i en token, kan vi bruke denne terminologien:
- Ett og ett ord: Unigram
- To og to ord: Bigrams
- Tre og tre ord: Trigrams
For å splitte tekst inn i unigram setter vi token = "words" i unnest_tokens-funksjonen. Dette er også default for funksjonen, så dersom vi ikke spesifiserer noen ting, så er det unigrams vi får.
no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
token = "words")
## # A tibble: 2,397 x 3
## # Groups: spor, titler [12]
## spor titler token
## <int> <chr> <chr>
## 1 1 Parentes forstyrrer
## 2 1 Parentes jeg
## 3 1 Parentes eller
## 4 1 Parentes har
## 5 1 Parentes du
## 6 1 Parentes tid
## 7 1 Parentes til
## 8 1 Parentes å
## 9 1 Parentes høre
## 10 1 Parentes på
## # ... with 2,387 more rows
For å hente ut bigrams, sett token = "ngrams" og n = 2. Kan du tenke deg hva vi ville fått dersom vi hadde satt n = 3?
no4 %>%
group_by(spor, titler) %>%
unnest_tokens(output = token,
input = tekst,
token = "ngrams",
n = 2)
## # A tibble: 2,385 x 3
## # Groups: spor, titler [12]
## spor titler token
## <int> <chr> <chr>
## 1 1 Parentes forstyrrer jeg
## 2 1 Parentes jeg eller
## 3 1 Parentes eller har
## 4 1 Parentes har du
## 5 1 Parentes du tid
## 6 1 Parentes tid til
## 7 1 Parentes til å
## 8 1 Parentes å høre
## 9 1 Parentes høre på
## 10 1 Parentes på meg
## # ... with 2,375 more rows
5.6 Word embeddings
Når vi skal jobbe med tekst, må vi finne en måte å gjøre om teksten til tall. Datamaskinen jobber best med tall. Prosessen med å gjøre om ord til tall kalles “vektorisering”. Det finnes flere måter å vektorisere på, deriblant:
- Sekk av ord (bag of words): Gir oss frekvensen av ord per dokument.
- TF-IDF: Gir oss frekvens av ord per dokument, vektet etter hvor hyppig ordet forekommer i dokumentmassen.
- Word embeddings: Gir oss en vektor i et lav-dimensjonalt rom for hvert ord.
Mange som jobber med NLP (natural language processing) henfaller til word embeddings fordi det har en del fordeler i forhold til å bruke frekvens:
- Det gir et estimat på likhet
- Det muliggjør automatisk generalisering
- Det kan (til dels) måle et ords mening
I tillegg får vi data som er mer tettpakket – kolonnene har ikke så mange nuller, noe som gir færre dimensjoner, noe som reduserer sjansen for overtilpasning.
Det finnes flere pakker for word embeddings i R, for eksempel word2vec, GloVe og fastText.
Her følger et eksempel med hvordan man kan bruke fastText for å lage word embeddings:
Steg 1: Som vanlig må vi huske å preprosessere teksten før vi setter i gang med analysene våre.
stoppord <- stopwords::stopwords("Norwegian") # Finner stoppord fra den norske bokmålslista til "stopwords" pakken
stoppord_boundary <- str_c("\\b", stoppord, "\\b", # Lager en vektor med "word boundary" for å ta ut ord fra en streng
collapse = "|") # Setter | mellom hver ord for å skille dem fra hverandre med "eller"-operator
no4_prepped <- no4 %>%
mutate(tekst = str_to_lower(tekst), # Setter all tekst til liten bokstav
tekst = str_replace_all(tekst, "[0-9]+", ""), # Fjerner tall fra teksten
tekst = str_squish(tekst), # Fjerner whitespace
tekst = str_replace_all(tekst, "\\b\\w{1,1}\\b", ""), # Fjerner enkeltbokstaver
tekst = str_replace_all(tekst, stoppord_boundary, ""), # Fjerner stoppord
tekst = str_replace_all(tekst, "[:punct:]", "")) # Fjerner all punktsetting
Steg 2: Fasttext er en algoritme utviklet av Facebook. De har laget den slik at den skal fungere for alle utviklere der ute, enten de jobber i terminalen, i Python, i Java, i R, eller i noe annet. Derfor krever de en input som er litt utenom det vanlige – et vanlig tekstdokument, altså en .txt fil. Dette kan vi lage i R med koden under.
no4_tekster <- tempfile() # Oppretter en midlertidig fil på PCen
writeLines(text = no4_prepped %>% pull(tekst), con = no4_tekster) # I denne filen skriver vi inn teksten fra datasettet.
Steg 3: Nå kan vi kjøre modellen for å lage word embeddings. Noen av valgene vi må ta er:
- Hvor stort skal kontekstvinduet være? Altså hvor mange ord foran og bak hovedordet skal algoritmen bruke for å forstå konteksten.
- Hvor mange dimensjoner skal det være? Her får vi automatisk 100 dimensjoner. For å endre dette måtte vi kjørt modellen via terminalen.
- Hvilken modell skal vi bruke? Fasttext tilbyr både
cbowogskipgram.
library(fastTextR)
ft_cbow <- ft_train(no4_tekster,
type = "cbow", # Velger cbow modell
control = ft_control(window_size = 5L)) # Setter kontekstvinduet til 5
ft_skipgram <- ft_train(no4_tekster,
type = "skipgram", # Velger skipgram modell
control = ft_control(window_size = 5L))
Vi kan finne ord-vektorene med ft_word_vectors. Legg merke til at de går til 100. Vi har altså 100 dimensjoner. Hadde vi brukt “sekk av ord”, hadde vi hatt like mange dimensjoner som vi har ord, altså nesten 1000. Vi har, med andre ord, redusert antall dimensjoner ganske kraftig.
ft_word_vectors(ft_cbow, c("fordi", "himmel"))
## [,1] [,2] [,3] [,4] [,5]
## fordi 0.0004229803 -3.126769e-05 0.0001531525 -0.0000911542 0.0005640839
## himmel 0.0003400762 -2.810812e-04 0.0003625524 -0.0001698947 -0.0002005034
## [,6] [,7] [,8] [,9] [,10]
## fordi 0.0001611611 9.468633e-06 -0.0005698582 2.882037e-04 1.239537e-04
## himmel 0.0002343899 -4.229283e-04 -0.0002758013 -2.557085e-05 5.703386e-05
## [,11] [,12] [,13] [,14] [,15]
## fordi -4.968944e-05 -0.0005556891 2.990265e-04 -4.937951e-04 0.0003009799
## himmel 2.508874e-04 -0.0001195825 7.999406e-07 -9.304351e-05 -0.0004802363
## [,16] [,17] [,18] [,19] [,20]
## fordi 0.0004996158 -0.0006972131 0.0003386495 2.059648e-04 0.0001499537
## himmel 0.0003660462 0.0002463926 -0.0001130784 1.981639e-05 -0.0005010382
## [,21] [,22] [,23] [,24] [,25]
## fordi -0.0006710046 -0.0002009657 -0.0005896706 -0.000539655 -6.203828e-04
## himmel 0.0002415862 0.0003886326 0.0002367772 -0.000117599 2.979174e-05
## [,26] [,27] [,28] [,29] [,30]
## fordi 0.0003445543 1.054367e-04 -7.411114e-05 0.0006279270 -0.0005203970
## himmel 0.0003994071 -1.302021e-05 4.160340e-05 0.0005499916 -0.0002487123
## [,31] [,32] [,33] [,34] [,35]
## fordi -1.410886e-04 0.0002264874 0.0006179944 -0.0002261649 -2.146827e-04
## himmel -7.161538e-05 -0.0002718160 -0.0003046337 0.0005525587 -8.663347e-05
## [,36] [,37] [,38] [,39] [,40]
## fordi 7.682834e-05 0.0006319320 0.0005043782 -0.0004292535 0.0007084003
## himmel 4.861114e-04 -0.0005046887 -0.0001433692 0.0004297458 0.0003608722
## [,41] [,42] [,43] [,44] [,45]
## fordi -0.0007128998 0.0001443866 0.0004862696 1.229172e-04 -0.0001680819
## himmel 0.0003313405 0.0005343112 0.0002021801 9.993793e-05 -0.0001391745
## [,46] [,47] [,48] [,49] [,50]
## fordi 4.094304e-04 0.0003071116 -0.0004071383 0.0006898485 0.0005943870
## himmel -6.519686e-05 -0.0002976863 -0.0001010489 0.0001225848 -0.0002687823
## [,51] [,52] [,53] [,54] [,55]
## fordi 0.0000633754 -0.0002592132 -3.742145e-04 -0.0003261300 -0.0005033756
## himmel -0.0004098963 -0.0001246714 -2.255533e-05 -0.0002819263 -0.0004302305
## [,56] [,57] [,58] [,59] [,60]
## fordi 0.0005689726 -0.0007093063 -0.0006017384 -0.0004089687 5.104363e-05
## himmel -0.0001421283 0.0005541204 0.0005207795 0.0003844925 -3.888329e-04
## [,61] [,62] [,63] [,64] [,65]
## fordi 0.0006562477 0.0004448018 5.268937e-05 0.0002097173 0.0006626237
## himmel -0.0002631767 -0.0003501290 -5.216807e-04 -0.0001643755 0.0001456836
## [,66] [,67] [,68] [,69] [,70]
## fordi 0.0004130471 0.0005810333 0.0001703538 -0.0004132454 -0.0005853543
## himmel -0.0002122321 -0.0002969235 -0.0004152645 0.0005410713 -0.0004272975
## [,71] [,72] [,73] [,74] [,75]
## fordi 0.0004889697 2.109571e-04 -0.0001353624 0.0001184932 -0.0003778868
## himmel 0.0002760292 6.502134e-05 -0.0001330448 -0.0004659508 -0.0002136038
## [,76] [,77] [,78] [,79] [,80]
## fordi -0.0001506362 0.0006121492 -0.0006414849 0.0002733775 -0.0004687168
## himmel -0.0003862265 0.0001411103 0.0001751131 0.0003818365 0.0000309874
## [,81] [,82] [,83] [,84] [,85]
## fordi -0.0002362550 -0.0004116326 2.613955e-05 -6.308833e-04 -0.0006163829
## himmel -0.0001708977 0.0005427501 1.457292e-04 8.805923e-05 -0.0003328912
## [,86] [,87] [,88] [,89] [,90]
## fordi 6.234720e-04 0.0003603076 -0.0002887875 -0.0004656472 6.827115e-05
## himmel 2.321036e-05 0.0003714993 0.0002933164 0.0004211639 1.341513e-04
## [,91] [,92] [,93] [,94] [,95]
## fordi 3.228633e-05 0.0004867699 0.0002592817 0.0002948412 -0.0005433591
## himmel 3.277051e-04 0.0003368929 -0.0002967137 0.0003651592 0.0003919543
## [,96] [,97] [,98] [,99] [,100]
## fordi -0.0001487845 -0.0002300390 -0.0005808019 -0.0002429863 0.0003132335
## himmel 0.0005056034 0.0005507146 0.0002853644 0.0001191367 0.0002650023
For å finne ut hvilke ord som likner mest, kontekstmessig, på et annet ord, kan vi bruke funksjonen ft_nearest_neighbors.
ft_nearest_neighbors(ft_cbow, "himmel", k = 5L)
## alltid egentlig fast alt ser
## 0.20526281 0.16303207 0.12392932 0.09339610 0.08974258
Som du ser, virker det ikke som modellen i særlig god grad klarer å fange opp hvilke ord som likner på “himmel”. Ved mindre vi har ekstremt store mengder med data å trene våre word embeddings på, er det ofte best å bruke ferdigtrent data. Du kan finne facebook sine ferdigtrente word embeddings i diverse språk her: https://fasttext.cc/docs/en/crawl-vectors.html
6 Veildedet læring
7 Ikke-veiledet læring
8 Ordbøker
9 Tekststatistikk
9.1 Likhet
9.2 Avstand
9.3 Lesbarhet
9.4 Uttrykk
10 Sentiment
10.1 NorSentLex
Det har lenge vært ganske lite ressurser for sentimentanalyse på norsk. Barnes et al. (2019) har ganske nylig satt sammen en stor ordbok med positive og negative ord i for både fullform og lemmatisert form med PoS-tags4. Disse ordbøkene bygger på en en oversatt og manuelt korrigert engelsk korpus av kundetilbakemeldinger (Hu and Liu 2004) og er pakket i både rå .txt-filer og .json-filer. Heldigvis har en tulling også konvertert dette til en pakke i R: NorSentLex (for øyblikket ikke på CRAN). For å laste inn/ned ordbøkene, kan du enten installere R-pakken med devtools::install_github("martigso/NorSentLex") eller bruke det du lærte i skrape-delen av denne notatboken på de originale filene. La oss illustrer med R-pakken:
# devtools::install_github("martigso/NorSentLex")
# library(NorSentLex)
# Ordbøker i fullform
names(nor_fullform_sent)
## [1] "negative" "positive"
# Ordbøker for lemma med PoS-tags
names(nor_lemma_sent)
## [1] "lemma_adj_negative" "lemma_adj_positive" "lemma_noun_negative"
## [4] "lemma_noun_positive" "lemma_padj_negative" "lemma_padj_positive"
## [7] "lemma_verb_negative" "lemma_verb_positive"
Hvis vi vil se på, for eksempel, noen positive ord i fullform, kan vi gå inn i listen nor_fullform_sent og listeelementet som heter $positive:
nor_fullform_sent$positive %>% head()
## [1] "absolutt" "absolutta" "absolutte" "absoluttene" "absolutter"
## [6] "absoluttet"
nor_fullform_sent$positive %>% tail()
## [1] "ønsket" "ønskete" "ønskt" "ønskte"
## [5] "øyeblikkelig" "øyeblikkelige"
nor_fullform_sent$positive %>% sample(., 6)
## [1] "lett" "kjæresten" "sympatisør" "underbart" "tilrå"
## [6] "dufte"
Det er ikke nødvendigvis alt som gir mening som positive og negative ord, med mindre man har i bakhodet at dette er basert på kundeanmeldelser. Så vær varsom!
Om vi videre vil bruke den lemmatiserte ordboken, kan vi også trekke dette ut enkelt fra de forskjellige elementene i nor_lemma_sent. Si at vi skal bruke bare positive substantiv:
nor_lemma_sent$lemma_noun_positive %>% sample(., 6)
## [1] "skarpsinn" "engel" "jubilant" "fortjeneste" "enighet"
## [6] "forsiktighet"
Nå når vi vet hvordan vi finner ordboken, gjenstår å lære hvordan vi bruker den. La oss bruke fullformord fra No.4-albumet data-mappen (no4.rda) som eksempel. Først splitter vi opp teksten i ord (tokens):
library(tidytext)
load("./data/no4.rda")
no4 <- no4 %>%
group_by(titler) %>%
unnest_tokens(ord, tekst)
Så kryss-refererer vi hvert ord med de positive og negative fullformordene i ordboken:
no4$pos_sent <- ifelse(no4$ord %in% nor_fullform_sent$positive, 1, 0)
no4$neg_sent <- ifelse(no4$ord %in% nor_fullform_sent$negative, 1, 0)
table(no4$pos_sent,
no4$neg_sent,
dnn = c("positiv", "negativ"))
## negativ
## positiv 0 1
## 0 2062 117
## 1 217 1
Som vi ser, er det faktisk noen flere negative ord enn positive i albument. Men overvekten av ord er nøytrale (0 på begge). Vi kan også summere opp sentiment over sangene, og se om det er noe forskjell i sentiment mellom dem:
no4_sent <- no4 %>%
group_by(titler) %>%
summarize(pos_sent = mean(pos_sent),
neg_sent = mean(neg_sent)) %>%
mutate(sent = pos_sent - neg_sent)
no4_sent
## # A tibble: 12 x 4
## titler pos_sent neg_sent sent
## <chr> <dbl> <dbl> <dbl>
## 1 Alt vi ikke er 0.100 0.0502 0.0502
## 2 Du trenger ikke å bli stor 0.0537 0.0604 -0.00671
## 3 En av de levende 0.0819 0.0395 0.0424
## 4 Feil sted 0.0374 0.0561 -0.0187
## 5 Hele livet (Ft. Fredrik Høyer) 0.0421 0.0383 0.00383
## 6 Hjemme hos meg 0.0853 0.0155 0.0698
## 7 Hold deg fast 0.147 0.0333 0.113
## 8 Hvilket vi 0.0337 0.0506 -0.0169
## 9 Parentes 0.0563 0.0423 0.0141
## 10 Regndanse i skinnjakke (Ft. Fredrik Høyer) 0.0254 0.00847 0.0169
## 11 Så lenge vi finnes 0.266 0.131 0.135
## 12 Våre beste år 0.115 0.0513 0.0641
Ikke alverden forskjell, men noen sanger er med positive enn negative og motsatt. La oss visualisere:
no4_sent %>%
mutate(neg_sent = neg_sent * -1) %>%
ggplot(., aes(x = str_c(sprintf("%02d", 1:12),
". ",
str_sub(titler, 1, 7),
"[...]"))) +
geom_point(aes(y = neg_sent, color = "Negativ")) +
geom_point(aes(y = pos_sent, color = "Positiv")) +
geom_point(aes(y = sent, color = "Snitt")) +
geom_linerange(aes(ymin = neg_sent, ymax = pos_sent), color = "gray40") +
scale_color_manual(values = c("red", "cyan", "gray70")) +
labs(x = NULL, y = "Sentiment", color = NULL) +
ggdark::dark_theme_minimal() +
theme(axis.text.x = element_text(angle = 90, vjust = .25, hjust = 0))